Coverage Report

Created: 2025-11-02 11:31

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\src\client\mod.rs
Line
Count
Source
1
//! Client implementation
2
3
#![deny(clippy::implicit_return)]
4
#![allow(clippy::needless_return, clippy::doc_overindented_list_items)]
5
#![warn(missing_docs)]
6
7
use log::{error, info, warn};
8
use std::fs::File;
9
use std::io::{self, BufReader};
10
use std::path::Path;
11
use std::time::Duration;
12
use windows::Win32::UI::Input::KeyboardAndMouse::VK_C;
13
14
use crate::utils::config::ClientConfig;
15
use crate::utils::windows::{get_console_title, WindowsApi};
16
use ssh2_config::{ParseRule, SshConfig};
17
use tokio::net::windows::named_pipe::NamedPipeClient;
18
use tokio::process::{Child, Command};
19
use tokio::{io::Interest, net::windows::named_pipe::ClientOptions};
20
use windows::Win32::System::Console::{
21
    INPUT_RECORD, INPUT_RECORD_0, KEY_EVENT, KEY_EVENT_RECORD, LEFT_ALT_PRESSED, RIGHT_ALT_PRESSED,
22
    SHIFT_PRESSED,
23
};
24
25
use crate::{
26
    serde::{deserialization::deserialize_input_record_0, SERIALIZED_INPUT_RECORD_0_LENGTH},
27
    utils::constants::{PIPE_NAME, PKG_NAME},
28
};
29
30
/// Possible results when reading from the named pipe and writing to the
31
/// current process's stdinput.
32
enum ReadWriteResult {
33
    /// We wrote all complete [INPUT_RECORD_0] sequences we read from
34
    /// the named pipe to stdin.
35
    Success {
36
        /// Incomplete [INPUT_RECORD_0] sequence.
37
        ///
38
        /// What we read from the named pipe is a serialized [INPUT_RECORD_0].`KeyEvent`.
39
        /// As this is simply a [`SERIALIZED_INPUT_RECORD_0_LENGTH`] byte long sequence and we try to read from the pipe until we
40
        /// have some of the data it can happen that during any one read/write iteration we don't
41
        /// read the full sequence so we must keep track of what we read for next iterations
42
        /// where we will be able to read the remainder of the sequence.
43
        remainder: Vec<u8>,
44
        /// List of [KEY_EVENT_RECORD]s we have read from the named pipe.
45
        ///
46
        /// Used to detect the `Alt + Shift + C` key combination used
47
        /// to close the console window after the client process encountered an unexpected error.
48
        key_event_records: Vec<KEY_EVENT_RECORD>,
49
    },
50
    /// Trying to read from the pipe would require us to wait for data.
51
    WouldBlock,
52
    /// Something went wrong.
53
    Err,
54
    /// The pipe was closed.
55
    Disconnect,
56
}
57
58
/// Write the given [INPUT_RECORD_0] to the console input buffer using the provided API.
59
///
60
/// # Arguments
61
///
62
/// * `api` - The Windows API implementation to use.
63
/// * `input_record` - The [INPUT_RECORD_0].`KeyEvent` input record to write.
64
3
fn write_console_input(api: &dyn WindowsApi, input_record: INPUT_RECORD_0) {
65
3
    let buffer: [INPUT_RECORD; 1] = [INPUT_RECORD {
66
3
        EventType: KEY_EVENT as u16,
67
3
        Event: input_record,
68
3
    }];
69
3
    let mut nb_of_events_written = 0u32;
70
3
    match api.write_console_input(&buffer, &mut nb_of_events_written) {
71
        Ok(_) => {
72
2
            if nb_of_events_written == 0 {
73
1
                error!(
"Failed to write console input"0
);
74
1
                error!(
"{:?}"0
,
api0
.
get_last_error0
());
75
1
            }
76
        }
77
        Err(_) => {
78
1
            error!(
"Failed to write console input"0
);
79
1
            error!(
"{:?}"0
,
api0
.
get_last_error0
());
80
        }
81
    };
82
3
}
83
84
/// Resolve the username from the provided value or SSH config.
85
///
86
/// # Arguments
87
///
88
/// * `username` - Optional username to use. If None, will try to resolve from SSH config.
89
/// * `host` - The hostname (without port) to connect to.
90
/// * `config` - The client configuration containing SSH config path.
91
///
92
/// # Returns
93
///
94
/// The resolved username.
95
12
fn resolve_username(username: Option<String>, host: &str, config: &ClientConfig) -> String {
96
12
    if let Some(
val8
) = username {
97
8
        return val;
98
4
    }
99
100
4
    let mut ssh_config = SshConfig::default();
101
4
    let ssh_config_path = Path::new(config.ssh_config_path.as_str());
102
4
    if ssh_config_path.exists() {
103
2
        let mut reader = BufReader::new(
104
2
            File::open(ssh_config_path).expect("Could not open SSH configuration file."),
105
2
        );
106
2
        ssh_config = SshConfig::default()
107
2
            .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
108
2
            .expect("Failed to parse SSH configuration file");
109
2
    }
110
4
    return ssh_config
111
4
        .query(<&str>::clone(&host))
112
4
        .user
113
4
        .unwrap_or_default();
114
12
}
115
116
/// Build the SSH arguments from the username, host, port, and config.
117
///
118
/// # Arguments
119
///
120
/// * `username`    - The username to connect with.
121
/// * `host`        - The hostname to connect to.
122
/// * `port`        - Optional port number (0-65535).
123
/// * `config`      - The client config indicating how to call the SSH program.
124
///
125
/// # Returns
126
///
127
/// A vector of arguments ready to be passed to the SSH command.
128
12
fn build_ssh_arguments(
129
12
    username: &str,
130
12
    host: &str,
131
12
    port: Option<u16>,
132
12
    config: &ClientConfig,
133
12
) -> Vec<String> {
134
12
    let username_host = format!("{username}@{host}");
135
136
12
    let mut arguments = replace_argument_placeholders(
137
12
        &config.arguments,
138
12
        &config.username_host_placeholder,
139
12
        &username_host,
140
    );
141
142
    // Add port arguments if port was specified
143
12
    if let Some(
port9
) = port {
144
9
        arguments.push("-p".to_string());
145
9
        arguments.push(port.to_string());
146
9
    
}3
147
148
12
    return arguments;
149
12
}
150
151
/// Launch the SSH process.
152
///
153
/// The process might overwrite the console title once it launched, so we wait for that
154
/// to happen and set the title again.
155
///
156
/// # Arguments
157
///
158
/// * `username`    - The username to connect with.
159
/// * `host`        - The hostname to connect to.
160
/// * `port`        - Optional port number (0-65535).
161
/// * `config`      - The client config indicating how to call the SSH program.
162
///
163
/// # Returns
164
///
165
/// The handle to created [Child] process.
166
0
async fn launch_ssh_process(
167
0
    username: &str,
168
0
    host: &str,
169
0
    port: Option<u16>,
170
0
    config: &ClientConfig,
171
0
) -> Child {
172
0
    let arguments = build_ssh_arguments(username, host, port, config);
173
0
    let child = Command::new(&config.program)
174
0
        .args(arguments.clone())
175
0
        .spawn()
176
0
        .unwrap_or_else(|err| {
177
0
            let args: String = arguments.join(" ");
178
0
            error!("{}", err);
179
0
            panic!(
180
0
                "Failed to launch process `{}` with arguments `{}`",
181
                config.program, args
182
            )
183
        });
184
0
    return child;
185
0
}
186
187
/// Read all available [INPUT_RECORD_0] from the named pipe and write them to the console input buffer using the provided API.
188
///
189
/// This function also extracts the [KEY_EVENT_RECORD]s, making them available to the caller via
190
/// `ReadWriteResult::Success` and handles incomple reads from the named pipe via the internal buffer.
191
///
192
/// The daemon might send a "keep alive packet", which is just [`SERIALIZED_INPUT_RECORD_0_LENGTH`] bytes of `1`s,
193
/// we ignore this.
194
///
195
/// # Arguments
196
///
197
/// * `api`                 - The Windows API implementation to use.
198
/// * `named_pipe_client`   - The [Windows named pipe][1] client that has successfully connected to
199
///                           the named pipe created by the daemon.
200
/// * `internal_buffer`     - Vector containing incomplete `SERIALIZED_INPUT_RECORD_0` sequences
201
///                           that were read in a previous call.
202
/// # Returns
203
///
204
/// A `ReadWriteResult` indicating whether we were able to read from the named pipe and write the available INPUT_RECORDs
205
/// to the console input buffer or not.
206
///
207
/// [1]: https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes
208
0
async fn read_write_loop(
209
0
    api: &dyn WindowsApi,
210
0
    named_pipe_client: &NamedPipeClient,
211
0
    internal_buffer: &mut Vec<u8>,
212
0
) -> ReadWriteResult {
213
0
    let mut buf: [u8; SERIALIZED_INPUT_RECORD_0_LENGTH * 10] =
214
0
        [0; SERIALIZED_INPUT_RECORD_0_LENGTH * 10];
215
0
    match named_pipe_client.try_read(&mut buf) {
216
        Ok(0) => {
217
            // Seems to only happen if the pipe is closed/server disconnects
218
            // indicating that the daemon has been closed.
219
            // Exit the client too in that case.
220
0
            return ReadWriteResult::Disconnect;
221
        }
222
0
        Ok(n) => {
223
0
            internal_buffer.extend(&mut buf[0..n].iter());
224
0
            let iter = internal_buffer.chunks_exact(SERIALIZED_INPUT_RECORD_0_LENGTH);
225
0
            let mut key_event_records: Vec<KEY_EVENT_RECORD> = Vec::new();
226
0
            for serialzied_input_record in iter.clone() {
227
0
                if is_keep_alive_packet(serialzied_input_record) {
228
0
                    continue;
229
0
                };
230
0
                let input_record = deserialize_input_record_0(serialzied_input_record);
231
0
                write_console_input(api, input_record);
232
0
                key_event_records.push(unsafe { input_record.KeyEvent });
233
            }
234
0
            return ReadWriteResult::Success {
235
0
                remainder: iter.remainder().to_vec(),
236
0
                key_event_records,
237
0
            };
238
        }
239
0
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
240
0
            return ReadWriteResult::WouldBlock;
241
        }
242
0
        Err(e) => {
243
0
            error!("{}", e);
244
0
            return ReadWriteResult::Err;
245
        }
246
    }
247
0
}
248
249
/// Checks if a key event represents the Alt+Shift+C combination.
250
///
251
/// # Arguments
252
///
253
/// * `key_event` - The key event record to check.
254
///
255
/// # Returns
256
///
257
/// `true` if the key event represents Alt+Shift+C, `false` otherwise.
258
8
fn is_alt_shift_c_combination(key_event: &KEY_EVENT_RECORD) -> bool {
259
8
    return (key_event.dwControlKeyState & LEFT_ALT_PRESSED >= 1
260
3
        || key_event.dwControlKeyState & RIGHT_ALT_PRESSED == 1)
261
6
        && key_event.dwControlKeyState & SHIFT_PRESSED >= 1
262
5
        && key_event.wVirtualKeyCode == VK_C.0;
263
8
}
264
265
/// Checks if a byte sequence represents a keep-alive packet.
266
///
267
/// # Arguments
268
///
269
/// * `packet` - The byte sequence to check.
270
///
271
/// # Returns
272
///
273
/// `true` if the packet is a keep-alive packet, `false` otherwise.
274
6
fn is_keep_alive_packet(packet: &[u8]) -> bool {
275
6
    return packet == [u8::MAX; SERIALIZED_INPUT_RECORD_0_LENGTH];
276
6
}
277
278
/// Replaces placeholders in SSH command arguments.
279
///
280
/// # Arguments
281
///
282
/// * `arguments` - The argument templates.
283
/// * `placeholder` - The placeholder string to replace.
284
/// * `replacement` - The value to replace the placeholder with.
285
///
286
/// # Returns
287
///
288
/// A vector of arguments with placeholders replaced.
289
12
fn replace_argument_placeholders(
290
12
    arguments: &[String],
291
12
    placeholder: &str,
292
12
    replacement: &str,
293
12
) -> Vec<String> {
294
12
    return arguments
295
12
        .iter()
296
30
        .
map12
(|arg| return arg.replace(placeholder, replacement))
297
12
        .collect();
298
12
}
299
300
/// The main run loop of the client.
301
///
302
/// Connects to the named pipe opened by the daemon, reads all input records from it
303
/// and replays them to the console input buffer of the given child process.
304
/// Handles the `Alt + Shift + C` key combination used to close the console window
305
/// after the child process encountered an unexpected error.
306
///
307
/// # Arguments
308
///
309
/// * `api` - The Windows API implementation to use.
310
/// * `child` - Handle to the running SSH process.
311
0
async fn run(api: &dyn WindowsApi, child: &mut Child) {
312
    // Many clients trying to open the pipe at the same time can cause
313
    // a file not found error, so keep trying until we managed to open it
314
0
    let named_pipe_client: NamedPipeClient = loop {
315
0
        match ClientOptions::new().open(PIPE_NAME) {
316
0
            Ok(named_pipe_client) => {
317
0
                break named_pipe_client;
318
            }
319
            Err(_) => {
320
0
                continue;
321
            }
322
        }
323
    };
324
0
    let mut child_error = false;
325
0
    let mut internal_buffer: Vec<u8> = Vec::new();
326
    loop {
327
0
        named_pipe_client
328
0
            .ready(Interest::READABLE)
329
0
            .await
330
0
            .unwrap_or_else(|err| {
331
0
                error!("{}", err);
332
0
                panic!("Named client pipe is not ready to be read",)
333
            });
334
335
0
        match read_write_loop(api, &named_pipe_client, &mut internal_buffer).await {
336
            ReadWriteResult::Success {
337
0
                remainder,
338
0
                key_event_records,
339
            } => {
340
0
                internal_buffer = remainder;
341
0
                if child_error {
342
0
                    for key_event in key_event_records.into_iter() {
343
0
                        if is_alt_shift_c_combination(&key_event) {
344
0
                            return;
345
0
                        }
346
                    }
347
0
                }
348
            }
349
            ReadWriteResult::WouldBlock | ReadWriteResult::Err => {
350
                // Sleep some time to avoid hogging 100% CPU usage.
351
0
                tokio::time::sleep(Duration::from_nanos(5)).await;
352
            }
353
            ReadWriteResult::Disconnect => {
354
0
                warn!("Encountered disconnect when trying to read from named pipe");
355
0
                break;
356
            }
357
        }
358
0
        match child.try_wait() {
359
0
            Ok(Some(exit_status)) => match exit_status.code().unwrap() {
360
                0 | 1 | 130 => {
361
                    // 0 -> last command successful
362
                    // 1 -> last command unsuccessful
363
                    // 130 -> last command cancelled (Ctrl + C)
364
0
                    info!(
365
0
                        "Application terminated, last exit code: {}",
366
0
                        exit_status.code().unwrap()
367
                    );
368
0
                    break;
369
                }
370
                _ => {
371
0
                    if !child_error {
372
0
                        println!("Failed to establish SSH connection: {exit_status}");
373
0
                        println!("Shift-Alt-C to exit");
374
0
                        child_error = true;
375
0
                    }
376
                }
377
            },
378
0
            Ok(None) => (
379
0
                // child is still running
380
0
            ),
381
0
            Err(e) => panic!("{}", e),
382
        }
383
    }
384
0
}
385
386
/// The entrypoint for the `client` subcommand with API dependency injection.
387
///
388
/// Spawns a tokio background thread to ensure the console window title is not replaced
389
/// by the name of the child process once its launched.
390
/// Starts the SSH process as child process.
391
/// Executes the main run loop.
392
///
393
/// # Arguments
394
///
395
/// * `api`         - The Windows API implementation to use.
396
/// * `host`        - The name of the host to connect to, optionally with `:port` suffix.
397
/// * `username`    - The username to be used.
398
///                   Will try to resolve the correct username from the ssh config
399
///                   if none is given.
400
/// * `cli_port`    - Optional port from CLI option. Inline port takes precedence.
401
/// * `config`      - A reference to the `ClientConfig`.
402
0
pub async fn main(
403
0
    api: &dyn WindowsApi,
404
0
    host: String,
405
0
    username: Option<String>,
406
0
    cli_port: Option<u16>,
407
0
    config: &ClientConfig,
408
0
) {
409
0
    let (host, inline_port) =
410
0
        host.rsplit_once(':')
411
0
            .map_or((host.as_str(), None), |(host, port)| {
412
0
                return (host, Some(port));
413
0
            });
414
0
    let inline_port = inline_port.and_then(|p| {
415
0
        return p
416
0
            .parse::<u16>()
417
0
            .map_err(|e| {
418
0
                warn!("Invalid port '{}': {}. Using default SSH port.", p, e);
419
0
            })
420
0
            .ok();
421
0
    });
422
    // Inline port takes precedence over CLI port
423
0
    let port = inline_port.or(cli_port);
424
425
    // Resolve username using SSH config if needed
426
0
    let resolved_username = resolve_username(username, host, config);
427
428
    // Create title for console window
429
0
    let title_host = if let Some(port) = port {
430
0
        format!("{host}:{port}")
431
    } else {
432
0
        host.to_string()
433
    };
434
0
    let username_host_title = format!("{resolved_username}@{title_host}");
435
0
    let console_title = format!("{PKG_NAME} - {username_host_title}");
436
0
    let title_task = {
437
0
        let console_title = console_title.clone();
438
0
        async move {
439
            loop {
440
                // Set the console title (child might overwrite it, so we have to keep checking it)
441
0
                if console_title != get_console_title(api) {
442
0
                    api.set_console_title(console_title.as_str())
443
0
                        .unwrap_or_else(|err| {
444
0
                            error!("Failed to set console title: {}", err);
445
0
                        });
446
0
                }
447
0
                tokio::time::sleep(Duration::from_millis(5)).await;
448
            }
449
        }
450
    };
451
0
    let child_task = async {
452
0
        let mut child = launch_ssh_process(&resolved_username, host, port, config).await;
453
0
        run(api, &mut child).await;
454
0
        return child;
455
0
    };
456
457
    // Use tokio::select to run both tasks concurrently
458
0
    let child = tokio::select! {
459
0
        child = child_task => child,
460
0
        _ = title_task => {
461
0
            panic!("Title task should never complete");
462
        }
463
    };
464
465
    // Make sure the client and all its subprocesses
466
    // are aware they need to shutdown.
467
0
    api.generate_console_ctrl_event(0, 0).unwrap_or_else(|err| {
468
0
        error!("{}", err);
469
0
        panic!("Failed to send `ctrl + c` to remaining client windows",)
470
    });
471
0
    drop(child);
472
0
}
473
474
#[cfg(test)]
475
#[path = "../tests/client/test_mod.rs"]
476
mod test_mod;